--- title: "07-Spring Cloud Gateway 安全验证完整解决方案" created: 2025-12-15 tags: - 博客 aliases: - Spring Cloud Gateway 安全验证完整解决方案 --- # Spring Cloud Gateway 安全验证完整解决方案 ## **1. 架构设计** ![[请求处理流程-4ec98476.jpg]] ## **2. Maven 依赖** ```xml 4.0.0 org.springframework.boot spring-boot-starter-parent 3.2.0 com.example gateway-security 1.0.0 Gateway Security Module Spring Cloud Gateway 安全验证模块 - 支持 RSA 签名验证 - 支持 RSA 数据加解密 - 支持 V1(仅签名) 和 V2(签名+加密) 两种模式 17 2023.0.0 2.0.40 org.springframework.cloud spring-cloud-dependencies ${spring-cloud.version} pom import org.springframework.cloud spring-cloud-starter-gateway org.springframework.boot spring-boot-starter-data-redis-reactive com.alibaba.fastjson2 fastjson2 ${fastjson2.version} commons-codec commons-codec org.projectlombok lombok true org.springframework.boot spring-boot-starter-test test io.projectreactor reactor-test test org.springframework.boot spring-boot-maven-plugin org.projectlombok lombok ``` ## **3. 配置文件** ### **3.1 application.yml** ```yaml server: port: 8080 spring: application: name: gateway-security # Redis 配置 - 用于缓存渠道密钥信息 data: redis: host: localhost port: 6379 password: database: 0 timeout: 3000ms lettuce: pool: max-active: 8 # 最大连接数 max-idle: 8 # 最大空闲连接 min-idle: 2 # 最小空闲连接 max-wait: -1ms # 获取连接最大等待时间 cloud: gateway: # 路由配置 routes: - id: user-service uri: lb://user-service predicates: - Path=/api/user/** - id: order-service uri: lb://order-service predicates: - Path=/api/order/** # 安全配置 security: # 白名单路径 - 这些路径不进行安全校验 skip-urls: - /api/public/** - /api/health/** - /api/doc/** - /swagger-ui/** - /v3/api-docs/** # 密钥缓存配置 cache: # 缓存前缀 key-prefix: "gateway:channel:" # 缓存过期时间(秒) expire-seconds: 3600 # 日志配置 logging: level: com.example.gateway: DEBUG org.springframework.cloud.gateway: INFO ``` ### **3.2 SecurityProperties.java - 配置属性类** ```java package com.example.gateway.config; import lombok.Data; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component; import java.util.ArrayList; import java.util.List; /** * 安全配置属性类 * *

从配置文件中读取安全相关的配置项,包括: *

* *

使用示例: *

 * security:
 *   skip-urls:
 *     - /api/public/**
 *   cache:
 *     key-prefix: "gateway:channel:"
 *     expire-seconds: 3600
 * 
* * @author Your Name * @since 1.0.0 */ @Data @Component @ConfigurationProperties(prefix = "security") public class SecurityProperties { /** * 白名单URL列表 *

匹配这些路径的请求将跳过安全验证,直接放行 *

支持 Ant 风格的路径匹配: *

*/ private List skipUrls = new ArrayList<>(); /** * 缓存配置 */ private CacheConfig cache = new CacheConfig(); /** * 缓存配置内部类 */ @Data public static class CacheConfig { /** * Redis 缓存键前缀 *

完整的缓存键格式:{keyPrefix}{channelCode} */ private String keyPrefix = "gateway:channel:"; /** * 缓存过期时间(秒) *

建议设置为 1 小时(3600秒), * 在密钥更新时需要主动清除缓存 */ private long expireSeconds = 3600; } } ``` ## **4. 数据模型** ### **4.1 ChannelDataVo.java - 渠道数据模型** ```java package com.example.gateway.model; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.io.Serial; import java.io.Serializable; /** * 渠道数据模型 * *

存储每个接入渠道(如:APP、H5、小程序等)的密钥信息。 * 每个渠道拥有独立的密钥对,实现渠道隔离和权限控制。 * *

密钥体系说明:

*
 * ┌─────────────────────────────────────────────────────────────┐
 * │                      签名密钥对                              │
 * │  signPublicKey  - 后端持有,用于验证前端签名                   │
 * │  (signPrivateKey - 前端持有,用于对请求签名,后端不存储)        │
 * ├─────────────────────────────────────────────────────────────┤
 * │                      数据加密密钥对                           │
 * │  dataPublicKey  - 后端持有,用于加密响应数据给前端              │
 * │  dataPrivateKey - 后端持有,用于解密前端发送的加密数据          │
 * └─────────────────────────────────────────────────────────────┘
 * 
* * @author Your Name * @since 1.0.0 */ @Data @Builder @NoArgsConstructor @AllArgsConstructor public class ChannelDataVo implements Serializable { @Serial private static final long serialVersionUID = 1L; /** * 渠道编码 *

唯一标识一个接入渠道,如: *

*/ private String code; /** * 渠道名称 *

渠道的可读名称,用于日志和监控展示 */ private String name; /** * 签名公钥(Base64编码) * *

用途:验证前端请求的签名 *

算法:SHA256withRSA *

流程: *

    *
  1. 前端使用对应的私钥对请求参数签名
  2. *
  3. 后端使用此公钥验证签名的有效性
  4. *
  5. 验签通过则继续处理,否则拒绝请求
  6. *
* *

安全说明: *

*/ private String signPublicKey; /** * 数据加密公钥(Base64编码) * *

用途:加密响应数据返回给前端 *

算法:RSA/ECB/PKCS1Padding *

流程: *

    *
  1. 后端处理完业务逻辑,得到响应数据
  2. *
  3. 使用此公钥加密响应数据
  4. *
  5. 前端使用对应的私钥解密获取明文
  6. *
* *

注意:这是前端数据密钥对的公钥, * 对应的私钥由前端保管用于解密响应 */ private String dataPublicKey; /** * 数据解密私钥(Base64编码) * *

用途:解密前端发送的加密业务数据 *

算法:RSA/ECB/PKCS1Padding *

流程: *

    *
  1. 前端使用后端数据公钥加密 businessBody
  2. *
  3. 后端使用此私钥解密得到明文
  4. *
  5. 将明文传递给下游微服务处理
  6. *
* *

安全说明: *

*/ private String dataPrivateKey; /** * 渠道状态 *

1-启用,0-禁用 *

禁用的渠道将拒绝所有请求 */ private Integer status; /** * 判断渠道是否启用 * @return true-启用,false-禁用 */ public boolean isEnabled() { return status != null && status == 1; } } ``` ### **4.2 GatewayRequest.java - 统一请求模型** ```java package com.example.gateway.model; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; /** * 统一网关请求模型 * *

所有经过网关的请求都必须遵循此格式。 * 这种统一格式便于安全校验和日志追踪。 * *

请求体示例(V1模式-仅签名):

*
 * {
 *   "code": "APP_IOS",
 *   "timestamp": 1703318400000,
 *   "nonce": "abc123xyz",
 *   "businessBody": "{\"userId\":\"10001\",\"action\":\"query\"}",
 *   "sign": "Base64编码的签名值"
 * }
 * 
* *

请求体示例(V2模式-签名+加密):

*
 * {
 *   "code": "APP_IOS",
 *   "timestamp": 1703318400000,
 *   "nonce": "abc123xyz",
 *   "businessBody": "RSA加密后的Base64字符串",
 *   "sign": "Base64编码的签名值"
 * }
 * 
* * @author Your Name * @since 1.0.0 */ @Data @Builder @NoArgsConstructor @AllArgsConstructor public class GatewayRequest { /** * 渠道编码 *

标识请求来源,用于获取对应的密钥配置 *

必填 */ private String code; /** * 时间戳(毫秒) *

请求发起时的时间戳,用于防止重放攻击 *

后端会校验时间戳与当前时间的差值,超过阈值(如5分钟)则拒绝 *

必填 */ private Long timestamp; /** * 随机字符串 *

每次请求生成的唯一随机串,配合时间戳防止重放攻击 *

后端可将 nonce 存入 Redis(设置过期时间),检测重复请求 *

必填,建议长度 16-32 字符 */ private String nonce; /** * 业务数据 *

实际的业务请求参数,格式为 JSON 字符串 *

V1模式:明文 JSON *

V2模式:RSA 加密后的 Base64 字符串 *

必填 */ private String businessBody; /** * 签名 *

对请求参数的数字签名,Base64 编码 * *

签名规则: *

    *
  1. 将参与签名的字段按 key 字典序排序
  2. *
  3. 拼接成 key1=value1&key2=value2 格式
  4. *
  5. 使用 SHA256withRSA 算法和前端私钥签名
  6. *
  7. 将签名结果 Base64 编码
  8. *
* *

参与签名的字段:businessBody, code, nonce, timestamp *

必填 */ private String sign; } ``` ### **4.3 GatewayResponse.java - 统一响应模型** ```java package com.example.gateway.model; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; /** * 统一网关响应模型 * *

网关返回给前端的统一响应格式。 * *

响应示例(V1模式-明文):

*
 * {
 *   "code": 200,
 *   "message": "success",
 *   "data": {
 *     "userId": "10001",
 *     "userName": "张三"
 *   }
 * }
 * 
* *

响应示例(V2模式-加密):

*
 * {
 *   "code": 200,
 *   "message": "success",
 *   "data": "RSA加密后的Base64字符串"
 * }
 * 
* * @author Your Name * @since 1.0.0 */ @Data @Builder @NoArgsConstructor @AllArgsConstructor public class GatewayResponse { /** * 响应码 * */ private Integer code; /** * 响应消息 */ private String message; /** * 响应数据 *

V1模式:明文对象 *

V2模式:加密后的字符串 */ private T data; /** * 构建成功响应 */ public static GatewayResponse success(T data) { return GatewayResponse.builder() .code(200) .message("success") .data(data) .build(); } /** * 构建错误响应 */ public static GatewayResponse error(Integer code, String message) { return GatewayResponse.builder() .code(code) .message(message) .build(); } } ``` ## **5. RSA 工具类** ```java package com.example.gateway.utils; import lombok.extern.slf4j.Slf4j; import org.apache.commons.codec.binary.Base64; import javax.crypto.Cipher; import java.io.ByteArrayOutputStream; import java.nio.charset.StandardCharsets; import java.security.*; import java.security.spec.PKCS8EncodedKeySpec; import java.security.spec.X509EncodedKeySpec; import java.util.Map; import java.util.TreeMap; /** * RSA 加解密工具类 * *

提供完整的 RSA 签名验证和数据加解密功能,支持: *

* *

RSA 密钥长度与加解密限制:

* * * * *
密钥长度最大加密长度解密块大小
1024 bit117 bytes128 bytes
2048 bit245 bytes256 bytes
*

本工具类使用分段加解密,突破长度限制。 * *

使用示例:

*
{@code
 * // 验签
 * Map params = new HashMap<>();
 * params.put("code", "APP_IOS");
 * params.put("timestamp", 1703318400000L);
 * params.put("businessBody", "...");
 * boolean valid = RsaUtils.verify(params, publicKey, sign);
 * 
 * // 解密
 * String plainText = RsaUtils.decrypt(cipherText, privateKey);
 * 
 * // 加密
 * String cipherText = RsaUtils.encrypt(plainText, publicKey);
 * }
* * @author Your Name * @since 1.0.0 */ @Slf4j public final class RsaUtils { // ==================== 常量定义 ==================== /** * 签名算法:SHA256withRSA *

使用 SHA-256 计算摘要,再用 RSA 私钥加密 */ public static final String SIGN_ALGORITHM = "SHA256withRSA"; /** * 密钥算法:RSA */ public static final String KEY_ALGORITHM = "RSA"; /** * 加密转换方式 *

ECB 模式 + PKCS1 填充,这是 RSA 的默认且最常用的组合 */ public static final String CIPHER_ALGORITHM = "RSA/ECB/PKCS1Padding"; /** * RSA 密钥长度(bit) *

2048 位是目前推荐的安全长度 */ public static final int KEY_SIZE = 2048; /** * RSA 最大加密明文长度(2048位密钥) *

计算公式:密钥长度/8 - 11(PKCS1填充占用11字节) *

2048/8 - 11 = 245 字节 */ private static final int MAX_ENCRYPT_BLOCK = KEY_SIZE / 8 - 11; /** * RSA 最大解密密文长度(2048位密钥) *

等于密钥长度/8 = 256 字节 */ private static final int MAX_DECRYPT_BLOCK = KEY_SIZE / 8; // ==================== 私有构造函数 ==================== /** * 私有构造函数,防止实例化 */ private RsaUtils() { throw new UnsupportedOperationException("Utility class cannot be instantiated"); } // ==================== 签名相关方法 ==================== /** * 构建签名内容 * *

将请求参数按照 key 的字典序排序后,拼接成标准格式的签名原文。 * 这是保证前后端签名一致的关键步骤。 * *

处理规则:

*
    *
  1. 使用 TreeMap 自动按 key 字典序排序
  2. *
  3. 排除 sign 字段(签名字段本身不参与签名)
  4. *
  5. 排除 null 和空字符串值
  6. *
  7. 拼接格式:key1=value1&key2=value2
  8. *
* *

示例:

*
     * 输入:{code=APP, timestamp=123, businessBody=data, sign=xxx}
     * 排序:businessBody, code, timestamp(字典序)
     * 输出:businessBody=data&code=APP×tamp=123
     * 
* * @param params 请求参数Map * @return 签名原文字符串 */ public static String buildSignContent(Map params) { if (params == null || params.isEmpty()) { return ""; } // 使用 TreeMap 自动按 key 字典序排序 Map sortedMap = new TreeMap<>(params); StringBuilder content = new StringBuilder(); for (Map.Entry entry : sortedMap.entrySet()) { String key = entry.getKey(); Object value = entry.getValue(); // 排除 sign 字段和空值 if (!"sign".equals(key) && value != null && !"".equals(value.toString())) { if (content.length() > 0) { content.append("&"); } content.append(key).append("=").append(value); } } return content.toString(); } /** * 验证签名 * *

使用公钥验证请求的签名是否有效。这是安全校验的第一道防线。 * *

验签流程:

*
    *
  1. 按照 {@link #buildSignContent} 规则构建签名原文
  2. *
  3. 将公钥字符串转换为 PublicKey 对象
  4. *
  5. 使用 SHA256withRSA 算法验证签名
  6. *
* *

为什么要先验签再解密?(Fail-Fast 原则)

*

验签的计算成本远低于解密。如果先解密,攻击者可以发送大量 * 无效请求来消耗服务器的 CPU 资源(DDoS攻击)。先验签可以快速 * 过滤掉无效请求。 * * @param params 请求参数Map(包含待验证的数据) * @param publicKeyStr Base64编码的公钥字符串 * @param sign Base64编码的签名字符串 * @return true-验签成功,false-验签失败 */ public static boolean verify(Map params, String publicKeyStr, String sign) { if (params == null || publicKeyStr == null || sign == null) { log.warn("验签参数不完整: params={}, publicKey={}, sign={}", params != null, publicKeyStr != null, sign != null); return false; } try { // 1. 构建签名原文 String content = buildSignContent(params); log.debug("验签原文: {}", content); // 2. 解码公钥 byte[] keyBytes = Base64.decodeBase64(publicKeyStr); X509EncodedKeySpec keySpec = new X509EncodedKeySpec(keyBytes); KeyFactory keyFactory = KeyFactory.getInstance(KEY_ALGORITHM); PublicKey publicKey = keyFactory.generatePublic(keySpec); // 3. 验证签名 Signature signature = Signature.getInstance(SIGN_ALGORITHM); signature.initVerify(publicKey); signature.update(content.getBytes(StandardCharsets.UTF_8)); boolean result = signature.verify(Base64.decodeBase64(sign)); log.debug("验签结果: {}", result); return result; } catch (Exception e) { log.error("验签异常: {}", e.getMessage(), e); return false; } } /** * 使用私钥对数据签名 * *

生成请求的数字签名,通常由前端调用。后端也可用于测试。 * * @param params 待签名的参数Map * @param privateKeyStr Base64编码的私钥字符串 * @return Base64编码的签名字符串 * @throws RuntimeException 签名失败时抛出 */ public static String sign(Map params, String privateKeyStr) { try { // 1. 构建签名原文 String content = buildSignContent(params); log.debug("签名原文: {}", content); // 2. 解码私钥 byte[] keyBytes = Base64.decodeBase64(privateKeyStr); PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(keyBytes); KeyFactory keyFactory = KeyFactory.getInstance(KEY_ALGORITHM); PrivateKey privateKey = keyFactory.generatePrivate(keySpec); // 3. 执行签名 Signature signature = Signature.getInstance(SIGN_ALGORITHM); signature.initSign(privateKey); signature.update(content.getBytes(StandardCharsets.UTF_8)); byte[] signBytes = signature.sign(); return Base64.encodeBase64String(signBytes); } catch (Exception e) { throw new RuntimeException("RSA签名失败", e); } } // ==================== 加解密相关方法 ==================== /** * 公钥加密(支持分段加密) * *

使用公钥对明文进行加密。本方法支持任意长度的数据, * 内部会自动进行分段处理。 * *

使用场景:

*
    *
  • 后端使用前端数据公钥加密响应数据
  • *
  • 前端使用后端数据公钥加密请求数据
  • *
* *

分段加密原理:

*

RSA 加密的数据长度有限制(2048位密钥最多加密245字节)。 * 对于超长数据,需要分段加密后拼接。 * * @param content 待加密的明文字符串 * @param publicKeyStr Base64编码的公钥字符串 * @return Base64编码的密文字符串 * @throws RuntimeException 加密失败时抛出 */ public static String encrypt(String content, String publicKeyStr) { if (content == null || content.isEmpty()) { return content; } try { // 1. 解码公钥 byte[] keyBytes = Base64.decodeBase64(publicKeyStr); X509EncodedKeySpec keySpec = new X509EncodedKeySpec(keyBytes); KeyFactory keyFactory = KeyFactory.getInstance(KEY_ALGORITHM); PublicKey publicKey = keyFactory.generatePublic(keySpec); // 2. 初始化加密器 Cipher cipher = Cipher.getInstance(CIPHER_ALGORITHM); cipher.init(Cipher.ENCRYPT_MODE, publicKey); // 3. 分段加密 byte[] data = content.getBytes(StandardCharsets.UTF_8); byte[] encryptedData = doFinalWithBlock(cipher, data, MAX_ENCRYPT_BLOCK); // 4. Base64 编码 return Base64.encodeBase64String(encryptedData); } catch (Exception e) { throw new RuntimeException("RSA加密失败", e); } } /** * 私钥解密(支持分段解密) * *

使用私钥对密文进行解密。本方法支持任意长度的加密数据, * 内部会自动进行分段处理。 * *

使用场景:

*
    *
  • 后端使用后端数据私钥解密请求中的 businessBody
  • *
  • 前端使用前端数据私钥解密响应中的 data
  • *
* * @param content Base64编码的密文字符串 * @param privateKeyStr Base64编码的私钥字符串 * @return 解密后的明文字符串 * @throws RuntimeException 解密失败时抛出 */ public static String decrypt(String content, String privateKeyStr) { if (content == null || content.isEmpty()) { return content; } try { // 1. 解码私钥 byte[] keyBytes = Base64.decodeBase64(privateKeyStr); PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(keyBytes); KeyFactory keyFactory = KeyFactory.getInstance(KEY_ALGORITHM); PrivateKey privateKey = keyFactory.generatePrivate(keySpec); // 2. 初始化解密器 Cipher cipher = Cipher.getInstance(CIPHER_ALGORITHM); cipher.init(Cipher.DECRYPT_MODE, privateKey); // 3. 分段解密 byte[] encryptedData = Base64.decodeBase64(content); byte[] decryptedData = doFinalWithBlock(cipher, encryptedData, MAX_DECRYPT_BLOCK); // 4. 转换为字符串 return new String(decryptedData, StandardCharsets.UTF_8); } catch (Exception e) { throw new RuntimeException("RSA解密失败: " + e.getMessage(), e); } } /** * 分段处理加解密 * *

将数据按指定块大小分段处理,解决 RSA 长度限制问题。 * * @param cipher 加密/解密器 * @param data 待处理的数据 * @param blockSize 每块的最大长度 * @return 处理后的数据 * @throws Exception 处理异常 */ private static byte[] doFinalWithBlock(Cipher cipher, byte[] data, int blockSize) throws Exception { int inputLen = data.length; int offset = 0; try (ByteArrayOutputStream out = new ByteArrayOutputStream()) { while (inputLen > offset) { int len = Math.min(inputLen - offset, blockSize); byte[] block = cipher.doFinal(data, offset, len); out.write(block); offset += len; } return out.toByteArray(); } } // ==================== 密钥生成方法 ==================== /** * 生成 RSA 密钥对 * *

生成用于签名/加密的 RSA 密钥对。建议使用 2048 位密钥长度。 * *

使用示例:

*
{@code
     * KeyPair keyPair = RsaUtils.generateKeyPair();
     * String publicKey = Base64.encodeBase64String(keyPair.getPublic().getEncoded());
     * String privateKey = Base64.encodeBase64String(keyPair.getPrivate().getEncoded());
     * }
* * @return RSA密钥对 * @throws RuntimeException 生成失败时抛出 */ public static KeyPair generateKeyPair() { try { KeyPairGenerator generator = KeyPairGenerator.getInstance(KEY_ALGORITHM); generator.initialize(KEY_SIZE, new SecureRandom()); return generator.generateKeyPair(); } catch (NoSuchAlgorithmException e) { throw new RuntimeException("生成RSA密钥对失败", e); } } /** * 生成 Base64 编码的密钥对 * * @return 包含公钥和私钥的字符串数组 [publicKey, privateKey] */ public static String[] generateKeyPairBase64() { KeyPair keyPair = generateKeyPair(); return new String[] { Base64.encodeBase64String(keyPair.getPublic().getEncoded()), Base64.encodeBase64String(keyPair.getPrivate().getEncoded()) }; } } ``` ## **6. 渠道数据服务** ### **6.1 ChannelDataService.java - 服务接口** ```typescript package com.example.gateway.service; import com.example.gateway.model.ChannelDataVo; import reactor.core.publisher.Mono; /** * 渠道数据服务接口 * *

提供渠道密钥数据的查询功能,支持多级缓存策略。 * *

缓存策略:

*
    *
  1. 一级缓存:本地缓存(可选,使用 Caffeine)
  2. *
  3. 二级缓存:Redis 分布式缓存
  4. *
  5. 三级存储:数据库持久化存储
  6. *
* *

缓存更新策略:

*
    *
  • 密钥更新时,主动清除相关缓存
  • *
  • 设置合理的过期时间,自动失效
  • *
  • 支持手动刷新指定渠道的缓存
  • *
* * @author Your Name * @since 1.0.0 */ public interface ChannelDataService { /** * 根据渠道编码获取渠道数据 * *

优先从缓存获取,缓存未命中则查询数据库并更新缓存。 * *

查询流程:

*
     * Redis 缓存 ──未命中──▶ 数据库查询 ──▶ 写入缓存 ──▶ 返回结果
     *     │
     *     └──命中──▶ 直接返回
     * 
* * @param code 渠道编码,如 "APP_IOS" * @return 渠道数据的 Mono,如果渠道不存在则返回 Mono.empty() */ Mono getChannelDataByCode(String code); /** * 刷新指定渠道的缓存 * *

当密钥更新时,调用此方法清除旧缓存。 * 下次查询时会自动加载新的密钥数据。 * * @param code 渠道编码 * @return 操作结果的 Mono */ Mono refreshCache(String code); /** * 验证渠道是否有效 * *

检查渠道是否存在且状态为启用。 * * @param code 渠道编码 * @return true-有效,false-无效或不存在 */ Mono isValidChannel(String code); } ``` ### **6.2 ChannelDataServiceImpl.java - 服务实现** ```java package com.example.gateway.service.impl; import com.alibaba.fastjson2.JSON; import com.example.gateway.config.SecurityProperties; import com.example.gateway.model.ChannelDataVo; import com.example.gateway.service.ChannelDataService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.redis.core.ReactiveStringRedisTemplate; import org.springframework.stereotype.Service; import reactor.core.publisher.Mono; import java.time.Duration; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; /** * 渠道数据服务实现类 * *

实现了基于 Redis 的分布式缓存策略,保证集群环境下的一致性。 * *

实现特点:

*
    *
  • 使用响应式 Redis 客户端,与 WebFlux 无缝集成
  • *
  • 缓存未命中时,加载数据并自动填充缓存
  • *
  • 支持缓存穿透防护(空值缓存)
  • *
* *

注意事项:

*
    *
  • 生产环境应替换 loadFromDatabase 方法,接入真实数据库
  • *
  • 私钥等敏感数据建议加密存储
  • *
* * @author Your Name * @since 1.0.0 */ @Slf4j @Service @RequiredArgsConstructor public class ChannelDataServiceImpl implements ChannelDataService { /** * 响应式 Redis 模板 */ private final ReactiveStringRedisTemplate redisTemplate; /** * 安全配置属性 */ private final SecurityProperties securityProperties; /** * 模拟数据库存储(生产环境请替换为真实数据库访问) * *

这里使用 Map 模拟数据库,实际项目中应该: *

    *
  • 使用 R2DBC 进行响应式数据库访问
  • *
  • 或使用传统 JPA/MyBatis 配合 subscribeOn(Schedulers.boundedElastic())
  • *
*/ private static final Map DATABASE = new ConcurrentHashMap<>(); // 静态初始化模拟数据(仅用于演示) static { // 注意:以下密钥仅为示例,生产环境请使用真实密钥 DATABASE.put("APP_IOS", ChannelDataVo.builder() .code("APP_IOS") .name("iOS客户端") .signPublicKey("MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQE...") // 替换为真实公钥 .dataPublicKey("MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQE...") // 替换为真实公钥 .dataPrivateKey("MIIEvgIBADANBgkqhkiG9w0BAQEFAAOCAQ8...") // 替换为真实私钥 .status(1) .build()); DATABASE.put("APP_ANDROID", ChannelDataVo.builder() .code("APP_ANDROID") .name("Android客户端") .signPublicKey("MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQE...") .dataPublicKey("MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQE...") .dataPrivateKey("MIIEvgIBADANBgkqhkiG9w0BAQEFAAOCAQ8...") .status(1) .build()); } /** * {@inheritDoc} * *

实现逻辑: *

    *
  1. 生成 Redis 缓存键
  2. *
  3. 尝试从 Redis 获取缓存数据
  4. *
  5. 如果缓存命中,反序列化后返回
  6. *
  7. 如果缓存未命中,从数据库加载并填充缓存
  8. *
*/ @Override public Mono getChannelDataByCode(String code) { if (code == null || code.trim().isEmpty()) { return Mono.empty(); } String cacheKey = buildCacheKey(code); return redisTemplate.opsForValue().get(cacheKey) // 缓存命中:反序列化 .map(json -> { log.debug("缓存命中: key={}", cacheKey); return JSON.parseObject(json, ChannelDataVo.class); }) // 缓存未命中:从数据库加载 .switchIfEmpty(Mono.defer(() -> loadAndCacheChannelData(code, cacheKey))) // 验证渠道状态 .filter(ChannelDataVo::isEnabled) .doOnNext(data -> log.debug("获取渠道数据成功: code={}", code)) .doOnError(e -> log.error("获取渠道数据失败: code={}, error={}", code, e.getMessage())); } /** * 从数据库加载数据并填充缓存 * * @param code 渠道编码 * @param cacheKey Redis缓存键 * @return 渠道数据 */ private Mono loadAndCacheChannelData(String code, String cacheKey) { return loadFromDatabase(code) .flatMap(data -> { // 序列化并写入缓存 String json = JSON.toJSONString(data); Duration expireDuration = Duration.ofSeconds( securityProperties.getCache().getExpireSeconds() ); return redisTemplate.opsForValue() .set(cacheKey, json, expireDuration) .doOnSuccess(success -> log.debug("缓存写入成功: key={}", cacheKey)) .thenReturn(data); }) // 处理空值(防止缓存穿透) .switchIfEmpty(Mono.defer(() -> { log.warn("渠道不存在: code={}", code); // 可选:缓存空值,设置较短的过期时间 return Mono.empty(); })); } /** * 从数据库加载渠道数据 * *

生产环境请替换此方法的实现! * *

推荐实现方式: *

{@code
     * // 使用 R2DBC
     * return channelRepository.findByCode(code);
     * 
     * // 或使用传统 JPA
     * return Mono.fromCallable(() -> channelRepository.findByCode(code))
     *     .subscribeOn(Schedulers.boundedElastic());
     * }
* * @param code 渠道编码 * @return 渠道数据 */ private Mono loadFromDatabase(String code) { log.debug("从数据库加载渠道数据: code={}", code); // 模拟数据库查询(生产环境替换为真实数据库访问) ChannelDataVo data = DATABASE.get(code); if (data != null) { return Mono.just(data); } else { return Mono.empty(); } } /** * {@inheritDoc} */ @Override public Mono refreshCache(String code) { String cacheKey = buildCacheKey(code); return redisTemplate.delete(cacheKey) .map(count -> count > 0) .doOnSuccess(deleted -> { if (deleted) { log.info("缓存刷新成功: code={}", code); } else { log.debug("缓存不存在,无需刷新: code={}", code); } }) .doOnError(e -> log.error("缓存刷新失败: code={}, error={}", code, e.getMessage())); } /** * {@inheritDoc} */ @Override public Mono isValidChannel(String code) { return getChannelDataByCode(code) .map(data -> data != null && data.isEnabled()) .defaultIfEmpty(false); } /** * 构建 Redis 缓存键 * * @param code 渠道编码 * @return 完整的缓存键 */ private String buildCacheKey(String code) { return securityProperties.getCache().getKeyPrefix() + code; } } ``` ## **7. 安全验证过滤器** ```java package com.example.gateway.filter; import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONObject; import com.example.gateway.config.SecurityProperties; import com.example.gateway.exception.SecurityException; import com.example.gateway.model.ChannelDataVo; import com.example.gateway.model.GatewayResponse; import com.example.gateway.service.ChannelDataService; import com.example.gateway.utils.RsaUtils; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.cloud.gateway.filter.GatewayFilterChain; import org.springframework.cloud.gateway.filter.GlobalFilter; import org.springframework.core.Ordered; import org.springframework.core.io.buffer.DataBuffer; import org.springframework.core.io.buffer.DataBufferUtils; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.http.server.reactive.ServerHttpRequestDecorator; import org.springframework.stereotype.Component; import org.springframework.util.AntPathMatcher; import org.springframework.util.PathMatcher; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import java.nio.charset.StandardCharsets; import java.util.Map; /** * 安全验证全局过滤器 * *

这是网关安全体系的核心组件,负责对所有进入的请求进行安全校验。 * *

处理流程:

*
 * 请求进入
 *     │
 *     ▼
 * ┌─────────────────┐
 * │  1. 白名单检查   │ ── 匹配 ──▶ 直接放行
 * └─────────────────┘
 *     │ 不匹配
 *     ▼
 * ┌─────────────────┐
 * │  2. 读取请求体   │
 * └─────────────────┘
 *     │
 *     ▼
 * ┌─────────────────┐
 * │  3. 验证签名     │ ── 失败 ──▶ 返回 401
 * │  (Fail-Fast)    │
 * └─────────────────┘
 *     │ 成功
 *     ▼
 * ┌─────────────────┐
 * │  4. 解密数据     │ ── 失败 ──▶ 返回 400
 * │  (仅V2模式)     │
 * └─────────────────┘
 *     │ 成功
 *     ▼
 * ┌─────────────────┐
 * │  5. 重建请求     │
 * │  传递给下游服务  │
 * └─────────────────┘
 * 
* *

Fail-Fast 原则:

*

验签的计算成本(仅计算哈希+公钥验证)远低于解密(私钥运算)。 * 通过先验签,可以快速过滤无效请求,防止攻击者利用大量无效加密数据 * 消耗服务器资源(DDoS攻击)。 * *

请求体重建:

*

在 Spring WebFlux 中,请求体(Request Body)是一个响应式流, * 只能被消费一次。为了让下游服务能够正常读取请求体,我们需要: *

    *
  1. 读取原始请求体
  2. *
  3. 进行验签和解密处理
  4. *
  5. 将处理后的数据重新封装为新的请求体
  6. *
  7. 使用 ServerHttpRequestDecorator 装饰原请求
  8. *
* * @author Your Name * @since 1.0.0 */ @Slf4j @Component @RequiredArgsConstructor public class SecurityFilter implements GlobalFilter, Ordered { /** * 渠道数据服务,用于获取密钥信息 */ private final ChannelDataService channelDataService; /** * 安全配置属性 */ private final SecurityProperties securityProperties; /** * 路径匹配器,支持 Ant 风格的路径表达式 */ private final PathMatcher pathMatcher = new AntPathMatcher(); /** * 加密模式请求头名称 *

值为 "v1" 时仅验签,值为 "v2" 时验签并解密 */ private static final String ENCRYPT_HEADER = "encrypt"; /** * V2 加密模式标识 */ private static final String ENCRYPT_MODE_V2 = "v2"; /** * 渠道编码在 Exchange Attributes 中的存储键 *

用于在响应过滤器中获取渠道信息 */ public static final String CHANNEL_DATA_ATTR = "gateway.channel.data"; /** * 加密模式在 Exchange Attributes 中的存储键 */ public static final String ENCRYPT_MODE_ATTR = "gateway.encrypt.mode"; /** * 过滤器主逻辑 * *

处理流程: *

    *
  1. 白名单检查:匹配则直接放行
  2. *
  3. 读取并缓存请求体
  4. *
  5. 执行安全校验(验签+解密)
  6. *
  7. 重建请求传递给下游
  8. *
*/ @Override public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { ServerHttpRequest request = exchange.getRequest(); String path = request.getURI().getPath(); // 1. 白名单检查 if (isSkipUrl(path)) { log.debug("白名单放行: path={}", path); return chain.filter(exchange); } // 2. 读取请求体并处理 return DataBufferUtils.join(request.getBody()) .flatMap(dataBuffer -> { // 读取请求体内容 byte[] bytes = new byte[dataBuffer.readableByteCount()]; dataBuffer.read(bytes); // 释放 DataBuffer,防止内存泄漏 DataBufferUtils.release(dataBuffer); String rawBody = new String(bytes, StandardCharsets.UTF_8); log.debug("收到请求: path={}, body={}", path, rawBody); // 3. 执行安全校验 return doSecurityCheck(rawBody, request.getHeaders(), exchange) .flatMap(newBody -> { // 4. 重建请求并传递给下游 return chain.filter(buildNewExchange(exchange, newBody)); }); }) // 处理空请求体的情况 .switchIfEmpty(Mono.defer(() -> { log.warn("请求体为空: path={}", path); return buildErrorResponse(exchange, HttpStatus.BAD_REQUEST, "请求体不能为空"); })) // 统一异常处理 .onErrorResume(e -> handleException(exchange, e)); } /** * 执行安全校验 * *

核心校验逻辑,包括: *

    *
  1. 解析请求参数
  2. *
  3. 获取渠道密钥
  4. *
  5. 验证签名(Fail-Fast)
  6. *
  7. 解密数据(V2模式)
  8. *
* * @param rawBody 原始请求体 * @param headers 请求头 * @param exchange Web交换对象 * @return 处理后的请求体(明文) */ private Mono doSecurityCheck(String rawBody, HttpHeaders headers, ServerWebExchange exchange) { try { // 1. 解析请求体 JSONObject bodyJson = JSON.parseObject(rawBody); if (bodyJson == null) { return Mono.error(new SecurityException("请求体格式错误", HttpStatus.BAD_REQUEST)); } // 2. 提取核心参数 String code = bodyJson.getString("code"); String sign = bodyJson.getString("sign"); String businessBody = bodyJson.getString("businessBody"); Long timestamp = bodyJson.getLong("timestamp"); String nonce = bodyJson.getString("nonce"); // 3. 参数校验 if (code == null || sign == null || businessBody == null) { return Mono.error(new SecurityException("缺少必要参数", HttpStatus.BAD_REQUEST)); } // 4. 时间戳校验(防重放攻击) if (!isValidTimestamp(timestamp)) { return Mono.error(new SecurityException("请求已过期", HttpStatus.BAD_REQUEST)); } // 5. 获取加密模式 String encryptMode = headers.getFirst(ENCRYPT_HEADER); // 6. 获取渠道密钥并验证 return channelDataService.getChannelDataByCode(code) .switchIfEmpty(Mono.error(new SecurityException("非法渠道: " + code, HttpStatus.FORBIDDEN))) .flatMap(channelData -> { // 保存渠道信息到 Exchange,供响应过滤器使用 exchange.getAttributes().put(CHANNEL_DATA_ATTR, channelData); exchange.getAttributes().put(ENCRYPT_MODE_ATTR, encryptMode); // 7. 验证签名(Fail-Fast) @SuppressWarnings("unchecked") Map paramsMap = bodyJson.toJavaObject(Map.class); boolean verifyResult = RsaUtils.verify( paramsMap, channelData.getSignPublicKey(), sign ); if (!verifyResult) { log.warn("签名验证失败: code={}", code); return Mono.error(new SecurityException("签名验证失败", HttpStatus.UNAUTHORIZED)); } log.debug("签名验证成功: code={}", code); // 8. 解密数据(仅V2模式) String finalBusinessBody = businessBody; if (ENCRYPT_MODE_V2.equalsIgnoreCase(encryptMode)) { try { finalBusinessBody = RsaUtils.decrypt( businessBody, channelData.getDataPrivateKey() ); log.debug("数据解密成功: code={}", code); } catch (Exception e) { log.error("数据解密失败: code={}, error={}", code, e.getMessage()); return Mono.error(new SecurityException("数据解密失败", HttpStatus.BAD_REQUEST)); } } // 9. 替换 businessBody 为明文 bodyJson.put("businessBody", finalBusinessBody); return Mono.just(bodyJson.toJSONString()); }); } catch (Exception e) { log.error("安全校验异常: {}", e.getMessage(), e); return Mono.error(new SecurityException("请求处理失败: " + e.getMessage(), HttpStatus.BAD_REQUEST)); } } /** * 重建 ServerWebExchange * *

将处理后的请求体封装到新的请求对象中。 * 使用 Decorator 模式包装原始请求,只替换 Body 部分。 * * @param exchange 原始Exchange * @param newBody 新的请求体内容 * @return 新的Exchange对象 */ private ServerWebExchange buildNewExchange(ServerWebExchange exchange, String newBody) { byte[] bodyBytes = newBody.getBytes(StandardCharsets.UTF_8); // 创建新的 DataBuffer DataBuffer buffer = exchange.getResponse().bufferFactory().wrap(bodyBytes); // 使用 Decorator 模式重建请求 ServerHttpRequest decoratedRequest = new ServerHttpRequestDecorator(exchange.getRequest()) { /** * 重写 getBody 方法,返回新的请求体 */ @Override public Flux getBody() { return Flux.just(buffer); } /** * 重写 getHeaders 方法,更新 Content-Length */ @Override public HttpHeaders getHeaders() { HttpHeaders headers = new HttpHeaders(); headers.putAll(super.getHeaders()); // 更新 Content-Length,否则下游服务可能读取不完整 headers.setContentLength(bodyBytes.length); // 移除原有的 Content-Length 头(如果有多个值) headers.remove(HttpHeaders.CONTENT_LENGTH); headers.add(HttpHeaders.CONTENT_LENGTH, String.valueOf(bodyBytes.length)); return headers; } }; // 构建新的 Exchange return exchange.mutate() .request(decoratedRequest) .build(); } /** * 检查是否为白名单URL * * @param path 请求路径 * @return true-在白名单中,false-不在 */ private boolean isSkipUrl(String path) { return securityProperties.getSkipUrls().stream() .anyMatch(pattern -> pathMatcher.match(pattern, path)); } /** * 验证时间戳有效性 * *

时间戳与当前时间相差超过5分钟的请求将被拒绝, * 这是防止重放攻击的重要手段。 * * @param timestamp 请求时间戳(毫秒) * @return true-有效,false-无效 */ private boolean isValidTimestamp(Long timestamp) { if (timestamp == null) { return false; } long currentTime = System.currentTimeMillis(); long diff = Math.abs(currentTime - timestamp); // 允许的时间差:5分钟 long maxDiff = 5 * 60 * 1000; return diff <= maxDiff; } /** * 统一异常处理 * * @param exchange Web交换对象 * @param e 异常对象 * @return Void Mono */ private Mono handleException(ServerWebExchange exchange, Throwable e) { log.error("请求处理异常: path={}, error={}", exchange.getRequest().getURI().getPath(), e.getMessage()); HttpStatus status = HttpStatus.INTERNAL_SERVER_ERROR; String message = "服务器内部错误"; if (e instanceof SecurityException se) { status = se.getStatus(); message = se.getMessage(); } return buildErrorResponse(exchange, status, message); } /** * 构建错误响应 * * @param exchange Web交换对象 * @param status HTTP状态码 * @param message 错误消息 * @return Void Mono */ private Mono buildErrorResponse(ServerWebExchange exchange, HttpStatus status, String message) { exchange.getResponse().setStatusCode(status); exchange.getResponse().getHeaders().setContentType(MediaType.APPLICATION_JSON); GatewayResponse response = GatewayResponse.error(status.value(), message); String responseBody = JSON.toJSONString(response); DataBuffer buffer = exchange.getResponse().bufferFactory() .wrap(responseBody.getBytes(StandardCharsets.UTF_8)); return exchange.getResponse().writeWith(Mono.just(buffer)); } /** * 过滤器执行顺序 * *

返回较小的值表示优先执行。 * 设置为 -100 确保此过滤器在大多数其他过滤器之前执行。 * * @return 顺序值 */ @Override public int getOrder() { return -100; } } ``` ## **8. 响应加密过滤器** ```java package com.example.gateway.filter; import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONObject; import com.example.gateway.model.ChannelDataVo; import com.example.gateway.utils.RsaUtils; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.reactivestreams.Publisher; import org.springframework.cloud.gateway.filter.GatewayFilterChain; import org.springframework.cloud.gateway.filter.GlobalFilter; import org.springframework.core.Ordered; import org.springframework.core.io.buffer.DataBuffer; import org.springframework.core.io.buffer.DataBufferFactory; import org.springframework.core.io.buffer.DataBufferUtils; import org.springframework.http.HttpHeaders; import org.springframework.http.server.reactive.ServerHttpResponse; import org.springframework.http.server.reactive.ServerHttpResponseDecorator; import org.springframework.stereotype.Component; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import java.nio.charset.StandardCharsets; /** * 响应加密全局过滤器 * *

对返回给前端的响应数据进行 RSA 加密,仅在 V2 加密模式下生效。 * *

处理流程:

*
 * 下游服务响应
 *     │
 *     ▼
 * ┌─────────────────────┐
 * │  检查加密模式        │ ── 非V2 ──▶ 原样返回
 * └─────────────────────┘
 *     │ V2模式
 *     ▼
 * ┌─────────────────────┐
 * │  读取响应体          │
 * └─────────────────────┘
 *     │
 *     ▼
 * ┌─────────────────────┐
 * │  提取 data 字段      │
 * └─────────────────────┘
 *     │
 *     ▼
 * ┌─────────────────────┐
 * │  使用前端公钥加密    │
 * └─────────────────────┘
 *     │
 *     ▼
 * ┌─────────────────────┐
 * │  替换 data 为密文    │
 * └─────────────────────┘
 *     │
 *     ▼
 *   返回加密响应
 * 
* *

密钥使用说明:

*

使用前端的数据公钥加密响应数据,前端使用对应的私钥解密。 * 这样即使响应被中间人截获,没有前端私钥也无法解密。 * *

注意事项:

*
    *
  • 只加密 data 字段,code 和 message 保持明文
  • *
  • 如果 data 为 null,不进行加密处理
  • *
  • 加密失败时返回原始响应,保证服务可用性
  • *
* * @author Your Name * @since 1.0.0 */ @Slf4j @Component @RequiredArgsConstructor public class ResponseEncryptFilter implements GlobalFilter, Ordered { /** * V2 加密模式标识 */ private static final String ENCRYPT_MODE_V2 = "v2"; /** * 过滤器主逻辑 * *

使用 Decorator 模式包装响应对象,在写入响应时进行加密处理。 */ @Override public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { // 获取加密模式 String encryptMode = exchange.getAttribute(SecurityFilter.ENCRYPT_MODE_ATTR); // 非V2模式,直接放行 if (!ENCRYPT_MODE_V2.equalsIgnoreCase(encryptMode)) { return chain.filter(exchange); } // 获取渠道数据 ChannelDataVo channelData = exchange.getAttribute(SecurityFilter.CHANNEL_DATA_ATTR); if (channelData == null) { log.warn("渠道数据为空,跳过响应加密"); return chain.filter(exchange); } // 使用 Decorator 包装响应 ServerHttpResponseDecorator decoratedResponse = new EncryptingResponseDecorator( exchange.getResponse(), channelData ); return chain.filter(exchange.mutate().response(decoratedResponse).build()); } /** * 加密响应装饰器 * *

继承 ServerHttpResponseDecorator,重写 writeWith 方法实现响应加密。 */ private class EncryptingResponseDecorator extends ServerHttpResponseDecorator { private final ChannelDataVo channelData; public EncryptingResponseDecorator(ServerHttpResponse delegate, ChannelDataVo channelData) { super(delegate); this.channelData = channelData; } /** * 重写响应写入方法 * *

拦截响应数据,进行加密处理后再写入。 */ @Override public Mono writeWith(Publisher body) { // 只处理 Flux 类型的响应体 if (body instanceof Flux) { Flux fluxBody = Flux.from(body); return super.writeWith( // 缓冲所有数据块 DataBufferUtils.join(fluxBody) .map(dataBuffer -> { // 读取原始响应内容 byte[] content = new byte[dataBuffer.readableByteCount()]; dataBuffer.read(content); DataBufferUtils.release(dataBuffer); String originalResponse = new String(content, StandardCharsets.UTF_8); log.debug("原始响应: {}", originalResponse); // 加密处理 String encryptedResponse = encryptResponseData(originalResponse); log.debug("加密响应: {}", encryptedResponse); // 更新 Content-Length byte[] newContent = encryptedResponse.getBytes(StandardCharsets.UTF_8); HttpHeaders headers = getDelegate().getHeaders(); headers.setContentLength(newContent.length); // 返回新的 DataBuffer DataBufferFactory bufferFactory = getDelegate().bufferFactory(); return bufferFactory.wrap(newContent); }) ); } return super.writeWith(body); } /** * 加密响应数据 * *

只加密 data 字段,保持响应结构不变。 * * @param jsonBody 原始响应JSON * @return 加密后的响应JSON */ private String encryptResponseData(String jsonBody) { try { JSONObject json = JSON.parseObject(jsonBody); if (json == null) { return jsonBody; } // 获取 data 字段 Object data = json.get("data"); if (data == null) { log.debug("响应 data 为空,跳过加密"); return jsonBody; } // 将 data 序列化为字符串 String dataStr; if (data instanceof String) { dataStr = (String) data; } else { dataStr = JSON.toJSONString(data); } // 使用前端数据公钥加密 String encryptedData = RsaUtils.encrypt(dataStr, channelData.getDataPublicKey()); // 替换 data 字段 json.put("data", encryptedData); log.debug("响应加密成功: code={}", channelData.getCode()); return json.toJSONString(); } catch (Exception e) { log.error("响应加密失败,返回原始数据: error={}", e.getMessage(), e); return jsonBody; } } } /** * 过滤器执行顺序 * *

设置为 -2,确保在 NettyWriteResponseFilter(-1) 之前执行, * 这样才能正确拦截和修改响应。 * * @return 顺序值 */ @Override public int getOrder() { return -2; } } ``` ## **9. 异常处理** ### **9.1 SecurityException.java - 安全异常** ```java package com.example.gateway.exception; import lombok.Getter; import org.springframework.http.HttpStatus; /** * 安全校验异常 * *

在安全校验过程中抛出的异常,携带 HTTP 状态码和错误消息。 * * @author Your Name * @since 1.0.0 */ @Getter public class SecurityException extends RuntimeException { /** * HTTP 状态码 */ private final HttpStatus status; /** * 构造函数 * * @param message 错误消息 * @param status HTTP状态码 */ public SecurityException(String message, HttpStatus status) { super(message); this.status = status; } /** * 构造函数(带原因) * * @param message 错误消息 * @param status HTTP状态码 * @param cause 原始异常 */ public SecurityException(String message, HttpStatus status, Throwable cause) { super(message, cause); this.status = status; } } ``` ### **9.2 GlobalExceptionHandler.java - 全局异常处理** ```java package com.example.gateway.exception; import com.alibaba.fastjson2.JSON; import com.example.gateway.model.GatewayResponse; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.web.reactive.error.ErrorWebExceptionHandler; import org.springframework.core.annotation.Order; import org.springframework.core.io.buffer.DataBuffer; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.server.reactive.ServerHttpResponse; import org.springframework.stereotype.Component; import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; import java.nio.charset.StandardCharsets; /** * 全局异常处理器 * *

捕获网关中所有未处理的异常,统一返回标准格式的错误响应。 * * @author Your Name * @since 1.0.0 */ @Slf4j @Order(-1) @Component public class GlobalExceptionHandler implements ErrorWebExceptionHandler { @Override public Mono handle(ServerWebExchange exchange, Throwable ex) { ServerHttpResponse response = exchange.getResponse(); // 如果响应已经提交,无法再修改 if (response.isCommitted()) { return Mono.error(ex); } // 确定HTTP状态码和错误消息 HttpStatus status; String message; if (ex instanceof SecurityException se) { status = se.getStatus(); message = se.getMessage(); } else if (ex instanceof ResponseStatusException rse) { status = HttpStatus.valueOf(rse.getStatusCode().value()); message = rse.getReason(); } else { status = HttpStatus.INTERNAL_SERVER_ERROR; message = "服务器内部错误"; log.error("未处理异常: ", ex); } log.warn("请求异常: path={}, status={}, message={}", exchange.getRequest().getURI().getPath(), status, message); // 构建错误响应 response.setStatusCode(status); response.getHeaders().setContentType(MediaType.APPLICATION_JSON); GatewayResponse errorResponse = GatewayResponse.error(status.value(), message); String responseBody = JSON.toJSONString(errorResponse); DataBuffer buffer = response.bufferFactory() .wrap(responseBody.getBytes(StandardCharsets.UTF_8)); return response.writeWith(Mono.just(buffer)); } } ``` ## **10. 测试示例** ### **10.1 RsaUtilsTest.java - 工具类测试** ```java package com.example.gateway.utils; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import java.security.KeyPair; import java.util.HashMap; import java.util.Map; import static org.junit.jupiter.api.Assertions.*; /** * RSA 工具类测试 * * @author Your Name * @since 1.0.0 */ class RsaUtilsTest { private String publicKey; private String privateKey; @BeforeEach void setUp() { // 生成测试用密钥对 String[] keyPair = RsaUtils.generateKeyPairBase64(); publicKey = keyPair[0]; privateKey = keyPair[1]; } @Test @DisplayName("测试签名原文构建 - 应按字典序排序") void testBuildSignContent() { Map params = new HashMap<>(); params.put("code", "APP_IOS"); params.put("timestamp", 1703318400000L); params.put("businessBody", "{\"userId\":\"10001\"}"); params.put("nonce", "abc123"); params.put("sign", "xxxxx"); // sign 应被排除 String content = RsaUtils.buildSignContent(params); // 验证排序:businessBody < code < nonce < timestamp assertTrue(content.startsWith("businessBody=")); assertTrue(content.contains("&code=")); assertTrue(content.contains("&nonce=")); assertTrue(content.endsWith("×tamp=1703318400000")); assertFalse(content.contains("sign=")); } @Test @DisplayName("测试签名与验签") void testSignAndVerify() { Map params = new HashMap<>(); params.put("code", "APP_IOS"); params.put("timestamp", System.currentTimeMillis()); params.put("businessBody", "{\"userId\":\"10001\"}"); // 签名 String sign = RsaUtils.sign(params, privateKey); assertNotNull(sign); // 验签 boolean result = RsaUtils.verify(params, publicKey, sign); assertTrue(result); } @Test @DisplayName("测试加密与解密 - 短文本") void testEncryptAndDecryptShortText() { String plainText = "Hello, RSA!"; // 加密 String cipherText = RsaUtils.encrypt(plainText, publicKey); assertNotNull(cipherText); assertNotEquals(plainText, cipherText); // 解密 String decryptedText = RsaUtils.decrypt(cipherText, privateKey); assertEquals(plainText, decryptedText); } @Test @DisplayName("测试加密与解密 - 长文本(测试分段加密)") void testEncryptAndDecryptLongText() { // 构建超过 245 字节的长文本 StringBuilder sb = new StringBuilder(); for (int i = 0; i < 100; i++) { sb.append("这是一段测试文本,用于测试RSA分段加密功能。"); } String plainText = sb.toString(); // 加密 String cipherText = RsaUtils.encrypt(plainText, publicKey); assertNotNull(cipherText); // 解密 String decryptedText = RsaUtils.decrypt(cipherText, privateKey); assertEquals(plainText, decryptedText); } @Test @DisplayName("测试验签失败 - 数据被篡改") void testVerifyFailOnTamperedData() { Map params = new HashMap<>(); params.put("code", "APP_IOS"); params.put("timestamp", System.currentTimeMillis()); params.put("businessBody", "{\"userId\":\"10001\"}"); // 签名 String sign = RsaUtils.sign(params, privateKey); // 篡改数据 params.put("businessBody", "{\"userId\":\"10002\"}"); // 验签应该失败 boolean result = RsaUtils.verify(params, publicKey, sign); assertFalse(result); } } ``` ### **10.2 SecurityFilterTest.java - 过滤器测试** ```java package com.example.gateway.filter; import com.alibaba.fastjson2.JSON; import com.example.gateway.config.SecurityProperties; import com.example.gateway.model.ChannelDataVo; import com.example.gateway.model.GatewayRequest; import com.example.gateway.service.ChannelDataService; import com.example.gateway.utils.RsaUtils; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.cloud.gateway.filter.GatewayFilterChain; import org.springframework.core.io.buffer.DataBuffer; import org.springframework.core.io.buffer.DefaultDataBufferFactory; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.mock.http.server.reactive.MockServerHttpRequest; import org.springframework.mock.web.server.MockServerWebExchange; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; import java.nio.charset.StandardCharsets; import java.security.KeyPair; import java.util.Arrays; import java.util.HashMap; import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.*; /** * 安全过滤器测试类 * *

测试覆盖场景:

*
    *
  • 白名单路径放行
  • *
  • 签名验证成功/失败
  • *
  • V2 模式数据解密
  • *
  • 请求时效性校验
  • *
  • 渠道有效性校验
  • *
  • 异常情况处理
  • *
* * @author Gateway Team * @version 1.0 */ @ExtendWith(MockitoExtension.class) @DisplayName("SecurityFilter 安全过滤器测试") class SecurityFilterTest { @Mock private ChannelDataService channelDataService; @Mock private GatewayFilterChain filterChain; private SecurityFilter securityFilter; private SecurityProperties securityProperties; // 测试用密钥对 private KeyPair signKeyPair; private KeyPair dataKeyPair; private ChannelDataVo testChannelData; @BeforeEach void setUp() { // 初始化配置 securityProperties = new SecurityProperties(); securityProperties.setEnabled(true); securityProperties.setSignTimeout(300000L); securityProperties.setSkipPaths(Arrays.asList("/api/health", "/api/public/**")); // 创建过滤器实例 securityFilter = new SecurityFilter(securityProperties, channelDataService); // 生成测试密钥对 signKeyPair = RsaUtils.generateKeyPair(); dataKeyPair = RsaUtils.generateKeyPair(); // 构建测试渠道数据 testChannelData = ChannelDataVo.builder() .code("TEST_CHANNEL") .name("测试渠道") .signPublicKey(RsaUtils.getPublicKeyString(signKeyPair)) .dataPrivateKey(RsaUtils.getPrivateKeyString(dataKeyPair)) .dataPublicKey(RsaUtils.getPublicKeyString(dataKeyPair)) .status(1) .securityLevel(2) .build(); // Mock filterChain 默认行为 when(filterChain.filter(any())).thenReturn(Mono.empty()); } // ==================== 辅助方法 ==================== /** * 创建有效的网关请求 */ private GatewayRequest createValidRequest(String businessBody) { Map params = new HashMap<>(); params.put("code", "TEST_CHANNEL"); params.put("timestamp", System.currentTimeMillis()); params.put("nonce", "test-nonce-" + System.nanoTime()); params.put("businessBody", businessBody); String sign = RsaUtils.sign(params, RsaUtils.getPrivateKeyString(signKeyPair)); return GatewayRequest.builder() .code("TEST_CHANNEL") .timestamp((Long) params.get("timestamp")) .nonce((String) params.get("nonce")) .businessBody(businessBody) .sign(sign) .build(); } /** * 创建 V2 模式的加密请求 */ private GatewayRequest createV2EncryptedRequest(String plainBusinessBody) { // 使用后端数据公钥加密业务数据 String encryptedBody = RsaUtils.encrypt( plainBusinessBody, RsaUtils.getPublicKeyString(dataKeyPair) ); Map params = new HashMap<>(); params.put("code", "TEST_CHANNEL"); params.put("timestamp", System.currentTimeMillis()); params.put("nonce", "test-nonce-" + System.nanoTime()); params.put("businessBody", encryptedBody); String sign = RsaUtils.sign(params, RsaUtils.getPrivateKeyString(signKeyPair)); return GatewayRequest.builder() .code("TEST_CHANNEL") .timestamp((Long) params.get("timestamp")) .nonce((String) params.get("nonce")) .businessBody(encryptedBody) .sign(sign) .build(); } /** * 创建 MockServerWebExchange */ private MockServerWebExchange createExchange(String path, String body, String encryptMode) { MockServerHttpRequest.BodyBuilder builder = MockServerHttpRequest .post(path) .contentType(MediaType.APPLICATION_JSON); if (encryptMode != null) { builder.header("encrypt", encryptMode); } MockServerHttpRequest request = builder.body(body); return MockServerWebExchange.from(request); } // ==================== 白名单测试 ==================== @Nested @DisplayName("白名单路径测试") class WhitelistTests { @Test @DisplayName("精确匹配白名单路径应该直接放行") void shouldSkipExactWhitelistPath() { // Given MockServerWebExchange exchange = createExchange("/api/health", "", null); // When Mono result = securityFilter.filter(exchange, filterChain); // Then StepVerifier.create(result) .verifyComplete(); verify(filterChain).filter(exchange); verifyNoInteractions(channelDataService); } @Test @DisplayName("通配符匹配白名单路径应该直接放行") void shouldSkipWildcardWhitelistPath() { // Given MockServerWebExchange exchange = createExchange("/api/public/info", "", null); // When Mono result = securityFilter.filter(exchange, filterChain); // Then StepVerifier.create(result) .verifyComplete(); verify(filterChain).filter(exchange); } @Test @DisplayName("安全校验禁用时应该直接放行") void shouldSkipWhenSecurityDisabled() { // Given securityProperties.setEnabled(false); MockServerWebExchange exchange = createExchange("/api/secure/data", "", null); // When Mono result = securityFilter.filter(exchange, filterChain); // Then StepVerifier.create(result) .verifyComplete(); verify(filterChain).filter(exchange); } } // ==================== 参数校验测试 ==================== @Nested @DisplayName("参数校验测试") class ParamValidationTests { @Test @DisplayName("空请求体应该返回 400 错误") void shouldReturn400WhenBodyIsEmpty() { // Given MockServerWebExchange exchange = createExchange("/api/secure/data", "", null); // When Mono result = securityFilter.filter(exchange, filterChain); // Then StepVerifier.create(result) .verifyComplete(); assertThat(exchange.getResponse().getStatusCode()) .isEqualTo(HttpStatus.BAD_REQUEST); verify(filterChain, never()).filter(any()); } @Test @DisplayName("无效 JSON 应该返回 400 错误") void shouldReturn400WhenJsonInvalid() { // Given MockServerWebExchange exchange = createExchange( "/api/secure/data", "invalid json {", null ); // When Mono result = securityFilter.filter(exchange, filterChain); // Then StepVerifier.create(result) .verifyComplete(); assertThat(exchange.getResponse().getStatusCode()) .isEqualTo(HttpStatus.BAD_REQUEST); } @Test @DisplayName("缺少必要参数应该返回 400 错误") void shouldReturn400WhenMissingRequiredParams() { // Given - 缺少 sign 字段 String body = """ { "code": "TEST_CHANNEL", "timestamp": %d, "nonce": "test-nonce", "businessBody": "test" } """.formatted(System.currentTimeMillis()); MockServerWebExchange exchange = createExchange("/api/secure/data", body, null); // When Mono result = securityFilter.filter(exchange, filterChain); // Then StepVerifier.create(result) .verifyComplete(); assertThat(exchange.getResponse().getStatusCode()) .isEqualTo(HttpStatus.BAD_REQUEST); } } // ==================== 时效性校验测试 ==================== @Nested @DisplayName("时效性校验测试") class TimestampValidationTests { @Test @DisplayName("过期的请求应该返回 401 错误") void shouldReturn401WhenRequestExpired() { // Given - 创建一个 10 分钟前的请求 GatewayRequest expiredRequest = GatewayRequest.builder() .code("TEST_CHANNEL") .timestamp(System.currentTimeMillis() - 600000L) // 10 分钟前 .nonce("test-nonce") .businessBody("test") .sign("fake-sign") .build(); String body = JSON.toJSONString(expiredRequest); MockServerWebExchange exchange = createExchange("/api/secure/data", body, null); // When Mono result = securityFilter.filter(exchange, filterChain); // Then StepVerifier.create(result) .verifyComplete(); assertThat(exchange.getResponse().getStatusCode()) .isEqualTo(HttpStatus.UNAUTHORIZED); } @Test @DisplayName("未来时间的请求应该返回 401 错误") void shouldReturn401WhenRequestFromFuture() { // Given - 创建一个 10 分钟后的请求 GatewayRequest futureRequest = GatewayRequest.builder() .code("TEST_CHANNEL") .timestamp(System.currentTimeMillis() + 600000L) .nonce("test-nonce") .businessBody("test") .sign("fake-sign") .build(); String body = JSON.toJSONString(futureRequest); MockServerWebExchange exchange = createExchange("/api/secure/data", body, null); // When Mono result = securityFilter.filter(exchange, filterChain); // Then StepVerifier.create(result) .verifyComplete(); assertThat(exchange.getResponse().getStatusCode()) .isEqualTo(HttpStatus.UNAUTHORIZED); } } // ==================== 渠道校验测试 ==================== @Nested @DisplayName("渠道校验测试") class ChannelValidationTests { @Test @DisplayName("无效渠道码应该返回 401 错误") void shouldReturn401WhenChannelNotFound() { // Given when(channelDataService.getChannelDataByCode("UNKNOWN_CHANNEL")) .thenReturn(Mono.empty()); GatewayRequest request = GatewayRequest.builder() .code("UNKNOWN_CHANNEL") .timestamp(System.currentTimeMillis()) .nonce("test-nonce") .businessBody("test") .sign("fake-sign") .build(); String body = JSON.toJSONString(request); MockServerWebExchange exchange = createExchange("/api/secure/data", body, null); // When Mono result = securityFilter.filter(exchange, filterChain); // Then StepVerifier.create(result) .verifyComplete(); assertThat(exchange.getResponse().getStatusCode()) .isEqualTo(HttpStatus.UNAUTHORIZED); } @Test @DisplayName("已禁用的渠道应该返回 401 错误") void shouldReturn401WhenChannelDisabled() { // Given ChannelDataVo disabledChannel = ChannelDataVo.builder() .code("TEST_CHANNEL") .status(0) // 禁用状态 .build(); when(channelDataService.getChannelDataByCode("TEST_CHANNEL")) .thenReturn(Mono.just(disabledChannel)); GatewayRequest request = createValidRequest("test"); String body = JSON.toJSONString(request); MockServerWebExchange exchange = createExchange("/api/secure/data", body, null); // When Mono result = securityFilter.filter(exchange, filterChain); // Then StepVerifier.create(result) .verifyComplete(); assertThat(exchange.getResponse().getStatusCode()) .isEqualTo(HttpStatus.UNAUTHORIZED); } } // ==================== 签名验证测试 ==================== @Nested @DisplayName("签名验证测试") class SignatureValidationTests { @Test @DisplayName("有效签名应该通过验证并放行") void shouldPassWithValidSignature() { // Given when(channelDataService.getChannelDataByCode("TEST_CHANNEL")) .thenReturn(Mono.just(testChannelData)); GatewayRequest request = createValidRequest("{\"userId\": 123}"); String body = JSON.toJSONString(request); MockServerWebExchange exchange = createExchange("/api/secure/data", body, null); // When Mono result = securityFilter.filter(exchange, filterChain); // Then StepVerifier.create(result) .verifyComplete(); verify(filterChain).filter(any()); } @Test @DisplayName("无效签名应该返回 401 错误") void shouldReturn401WithInvalidSignature() { // Given when(channelDataService.getChannelDataByCode("TEST_CHANNEL")) .thenReturn(Mono.just(testChannelData)); // 创建一个带错误签名的请求 GatewayRequest request = GatewayRequest.builder() .code("TEST_CHANNEL") .timestamp(System.currentTimeMillis()) .nonce("test-nonce") .businessBody("test") .sign("invalid-signature-base64") .build(); String body = JSON.toJSONString(request); MockServerWebExchange exchange = createExchange("/api/secure/data", body, null); // When Mono result = securityFilter.filter(exchange, filterChain); // Then StepVerifier.create(result) .verifyComplete(); assertThat(exchange.getResponse().getStatusCode()) .isEqualTo(HttpStatus.UNAUTHORIZED); verify(filterChain, never()).filter(any()); } @Test @DisplayName("签名被篡改应该返回 401 错误") void shouldReturn401WhenSignatureTampered() { // Given when(channelDataService.getChannelDataByCode("TEST_CHANNEL")) .thenReturn(Mono.just(testChannelData)); // 创建有效请求,然后修改 businessBody GatewayRequest request = createValidRequest("{\"userId\": 123}"); request.setBusinessBody("{\"userId\": 456}"); // 篡改数据 String body = JSON.toJSONString(request); MockServerWebExchange exchange = createExchange("/api/secure/data", body, null); // When Mono result = securityFilter.filter(exchange, filterChain); // Then StepVerifier.create(result) .verifyComplete(); assertThat(exchange.getResponse().getStatusCode()) .isEqualTo(HttpStatus.UNAUTHORIZED); } } // ==================== V2 加密模式测试 ==================== @Nested @DisplayName("V2 加密模式测试") class V2EncryptionTests { @Test @DisplayName("V2 模式应该成功解密业务数据") void shouldDecryptBusinessBodyInV2Mode() { // Given when(channelDataService.getChannelDataByCode("TEST_CHANNEL")) .thenReturn(Mono.just(testChannelData)); String plainBusinessBody = "{\"userId\": 123, \"action\": \"query\"}"; GatewayRequest request = createV2EncryptedRequest(plainBusinessBody); String body = JSON.toJSONString(request); MockServerWebExchange exchange = createExchange("/api/secure/data", body, "v2"); // When Mono result = securityFilter.filter(exchange, filterChain); // Then StepVerifier.create(result) .verifyComplete(); verify(filterChain).filter(any()); } @Test @DisplayName("V2 模式解密失败应该返回 401 错误") void shouldReturn401WhenDecryptFails() { // Given when(channelDataService.getChannelDataByCode("TEST_CHANNEL")) .thenReturn(Mono.just(testChannelData)); // 创建一个带无法解密的 businessBody 的请求 Map params = new HashMap<>(); params.put("code", "TEST_CHANNEL"); params.put("timestamp", System.currentTimeMillis()); params.put("nonce", "test-nonce"); params.put("businessBody", "not-a-valid-encrypted-base64"); String sign = RsaUtils.sign(params, RsaUtils.getPrivateKeyString(signKeyPair)); GatewayRequest request = GatewayRequest.builder() .code("TEST_CHANNEL") .timestamp((Long) params.get("timestamp")) .nonce((String) params.get("nonce")) .businessBody("not-a-valid-encrypted-base64") .sign(sign) .build(); String body = JSON.toJSONString(request); MockServerWebExchange exchange = createExchange("/api/secure/data", body, "v2"); // When Mono result = securityFilter.filter(exchange, filterChain); // Then StepVerifier.create(result) .verifyComplete(); assertThat(exchange.getResponse().getStatusCode()) .isEqualTo(HttpStatus.UNAUTHORIZED); } @Test @DisplayName("V1 模式不应该尝试解密") void shouldNotDecryptInV1Mode() { // Given when(channelDataService.getChannelDataByCode("TEST_CHANNEL")) .thenReturn(Mono.just(testChannelData)); // 明文的 businessBody GatewayRequest request = createValidRequest("{\"userId\": 123}"); String body = JSON.toJSONString(request); MockServerWebExchange exchange = createExchange("/api/secure/data", body, "v1"); // When Mono result = securityFilter.filter(exchange, filterChain); // Then StepVerifier.create(result) .verifyComplete(); verify(filterChain).filter(any()); } } // ==================== 集成测试 ==================== @Nested @DisplayName("完整流程集成测试") class IntegrationTests { @Test @DisplayName("完整 V1 流程:验签通过后应该放行") void shouldCompleteV1FlowSuccessfully() { // Given when(channelDataService.getChannelDataByCode("TEST_CHANNEL")) .thenReturn(Mono.just(testChannelData)); String businessData = """ { "userId": 12345, "action": "queryBalance", "params": { "accountType": "savings" } } """; GatewayRequest request = createValidRequest(businessData); String body = JSON.toJSONString(request); MockServerWebExchange exchange = createExchange("/api/account/balance", body, "v1"); // When Mono result = securityFilter.filter(exchange, filterChain); // Then StepVerifier.create(result) .verifyComplete(); verify(filterChain).filter(any()); verify(channelDataService).getChannelDataByCode("TEST_CHANNEL"); } @Test @DisplayName("完整 V2 流程:验签并解密后应该放行") void shouldCompleteV2FlowSuccessfully() { // Given when(channelDataService.getChannelDataByCode("TEST_CHANNEL")) .thenReturn(Mono.just(testChannelData)); String plainBusinessData = """ { "userId": 12345, "action": "transfer", "params": { "toAccount": "6222021234567890", "amount": 1000.00 } } """; GatewayRequest request = createV2EncryptedRequest(plainBusinessData); String body = JSON.toJSONString(request); MockServerWebExchange exchange = createExchange("/api/account/transfer", body, "v2"); // When Mono result = securityFilter.filter(exchange, filterChain); // Then StepVerifier.create(result) .verifyComplete(); verify(filterChain).filter(any()); } } } ``` ---